Maeiee Weekly No.25:用 Flutter 开发窗口管理器
Utopia 是一个开源窗口管理器(Window Manager)框架,特色是使用 Flutter 开发。基于它开发而成的桌面环境有 pangolin_desktop,它是 dahliaOS 系统的桌面环境。
最近我在折腾 Punk OS 这个 SideProject,它可以简单理解 OS in App,运行在 App 里的操作系统(用户空间)。基于 Flutter 技术,Punk OS 可以跨端运行。不论是在 WIndows 下,还是 Android 下,打开 Punk OS 应用,就仿佛进入了一个新的系统。
Punk OS 也需要一个桌面环境,于是本周我主要对 Utopia、pangolin_desktop 进行研究。本周主题是对这些项目的源码研究,对桌面管理器和如何用 Flutter 开发窗口管理器感兴趣的小伙伴,欢迎阅读。
utopia 对窗口的抽象
窗口是界面的容器,具体来说,包括几个部分:
- 窗口 ID:窗口的唯一标识
- 视图界面内容:即 App 的 UI
- 装饰器:标题栏(应用名称、最小化、最大化、关闭按钮、边框阴影)
- 窗口事件:缩放、拖拽、吸附
- 窗口特征:是否可聚焦、最小尺寸、最大尺寸、是否允许缩放、窗口形状、标题栏央视
围绕这几个部分,可以将架构抽象为状态层和展示层:
- 状态层(LayoutState):窗口尺寸、位置、是否置顶、吸附状态、是否最小化、是否最大化
- 视图层(WindowWrapper):包含 App 的 UI 内容(content),根据窗口特征执行绘制。
WindowFeature 窗口特征责任链
窗口自身包含一系列特性:是否可缩放,是否可聚焦(focusable),窗口的形状,窗口背景,以及工具栏、窗口边框的背景。
在前端组件化开发范式中,该如何优雅地支持这些功能呢?
在 utopia 中采取了责任链模式。首先定义了 WindowFeature 这个基类:
abstract class WindowFeature {
const WindowFeature();
Widget build(BuildContext context, Widget content);
Set<WindowPropertyKey> get requiredProperties => {};
}
其中包含两个方法:
- requiredProperties:用于传入参数
- build:责任链方法,传入一个组件内容,并返回一个新的组件
窗口的每一个特性都是一个 WindowFeature,在构建 WindowEntry 时,传入一个 WindowFeature 的列表。在实际构建窗口时,通过递归执行责任链:
Widget _buildFeatures(BuildContext context, [int index = 0]) {
if (index >= widget.features.length) {
return SizedBox.expand(child: widget.content);
}
return widget.features[index]
.build(context, _buildFeatures(context, index + 1));
}
这样便实现了:WindowFeatures 对于窗口内容(widget.content)的层层包装。
这种设计带来的好处是非常灵活。如果要添加新的窗口特性,实现一个新的 WindowFeature 即可。
同样,如果要更改已有的特性,比如更换标题栏样式,也只需要实现一个新的 WindowFeature,将老的替换掉即可。
窗口的尺寸与大小
在组件化的 UI 框架中,基于响应式开发,或称之为 MVVM 范式,实现窗口的大小与尺寸是非常方便的。
组件化的 UI 框架基于声明式开发理念,我们重点关注对于状态的描述。
在 Utopia 中,相关的实现是:
LayoutInfo
首先有一个抽象类 LayoutInfo,定义了与窗口布局相关的属性:
abstract class LayoutInfo {
/// 窗口尺寸
final Size size;
/// 窗口位置
final Offset position;
/// 是否总是位于最顶
final bool alwaysOnTop;
/// 位于最顶也分为多种类型
final AlwaysOnTopMode alwaysOnTopMode;
/// 吸附模式
final WindowDock dock;
/// 是否最小化
final bool minimized;
/// 是否全屏
final bool fullscreen;
/// Const constructor to allow subclasses to be const.
const LayoutInfo({……});
/// This method is required in order to support overriding the layout info from
/// the [WindowEntry.newInstance] method.
LayoutInfo copyWith({……});
/// Creates the associated [LayoutState] populating the fields that are needed
/// for it to properly work. Should not be overridden or used directly, reserved
/// for the library internal use.
LayoutState createStateInternal([WindowEventHandler? eventHandler]) {
final LayoutState state = createState();
state._info = this;
state._eventHandler = eventHandler;
return state;
}
/// This method should return a newly created [LayoutState] subclass instance,
/// similarly to how [StatefulWidget.createState] works.
/// 抽象方法
@protected
@factory
LayoutState createState();
}
该类有一个抽象方法 createState,返回的类型是 LayoutState。LayoutState 继承自 ChangeNotifier,通过 Provider 实现对实际窗口 Widget 的响应式控制。具体实现略过。
LayoutInfo 与 LayoutState 的绑定是在 createStateInternal 中,可以看到,LayoutInfo 把自己传入 LayoutState 实例中,同时也把 WindowEventHandler 传入其中。
与窗口控件绑定
ChangeNotifier 响应式的源头有了,咋哪里跟窗口建立起实际关联呢?
答案是在 WindowEntry 的 newInstance 方法中,该方法相当于创建一个实例化窗口出来。
LiveWindowEntry newInstance({
Widget? content, // 窗口内容
WindowEventHandler? eventHandler, // 窗口事件管理
LayoutInfo Function(LayoutInfo info)? overrideLayout,
Map<WindowPropertyKey, Object?> overrideProperties = const {}, // 窗口属性
}) {
……
final LayoutInfo info = overrideLayout?.call(layoutInfo) ?? layoutInfo;
return LiveWindowEntry._(
content: content ?? const SizedBox(),
layoutState: info.createStateInternal(eventHandler),
features: features,
eventHandler: eventHandler,
registry: WindowPropertyRegistry(initialData: completedProperties),
);
}
把几个参与一起,最终在 LiveWindowEntry._ 中完成绑定:
LiveWindowEntry._({
required this.content,
required this.layoutState,
required this.features,
required this.registry,
this.eventHandler,
}) : _view = MultiProvider(
providers: [
ChangeNotifierProvider.value(value: registry),
ChangeNotifierProvider.value(value: layoutState),
Provider.value(value: eventHandler),
],
key: GlobalKey(),
child: WindowWrapper(
features: features,
content: content,
key: ValueKey(registry.info.id),
),
);
这里看到了 layoutState 和 eventHandler、registry 都是 Provider,而窗口内容是 child,实现了响应式控制。
还记得 WindowWrapper 是啥吗?这是前一节中介绍的窗口特性装饰响应链!
窗口整体管理
前面介绍了单个窗口内部如何维护状态。一个桌面环境是由多窗口组成的,需要有一个总管,这项任务由 WindowHierarchy 和 WindowHierarchyController 负责。
WindowHierarchyController
存储所有窗口的状态,以及窗口管理器自身状态。具体代码如下:
class WindowHierarchyController with ChangeNotifier {
late final _WindowHierarchyInternalState _state;
// 所有窗口状态
final List<LiveWindowEntry> _entries = [];
// 焦点序列,以窗口 id 表征
final List<String> _focusHierarchy = [];
// 窗口管理器的不可用区域
EdgeInsets _wmInsets = EdgeInsets.zero;
/// 获取允许显示在任务栏上的窗口
List<LiveWindowEntry> get entries => _entries
.where((e) => e.registry.info.showOnTaskbar)
.toList();
/// 获取所有窗口的浅拷贝
List<LiveWindowEntry> get rawEntries => List.unmodifiable(_entries);
/// 获取焦点窗口的浅拷贝
List<String> get focusHierarchy => List.unmodifiable(_focusHierarchy);
// 关联 WindowHierarchy 的内部状态
void _provideState(_WindowHierarchyInternalState state) {
_state = state;
_initialized = true;
}
/// 创建窗口实例
void addWindowEntry(LiveWindowEntry entry) {
_checkForInitialized();
_entries.add(entry);
_focusHierarchy.add(entry.registry.info.id);
notifyListeners();
_state._requestRebuild();
}
/// 删除窗口实例
void removeWindowEntry(String id) {
_checkForInitialized();
final int entryIndex =
_entries.indexWhere((element) => element.registry.info.id == id);
_entries.removeAt(entryIndex).dispose();
_focusHierarchy.remove(id);
notifyListeners();
_state._requestRebuild();
}
/// 请求焦点
void requestEntryFocus(String id) {
_checkForInitialized();
final int idIndex = _focusHierarchy.indexWhere((element) => element == id);
final String poppedId = _focusHierarchy.removeAt(idIndex);
_focusHierarchy.add(poppedId);
notifyListeners();
_state._requestRebuild();
}
/// 获取窗口管理器的不可用区域
EdgeInsets get wmInsets => _wmInsets;
set wmInsets(EdgeInsets value) {
_wmInsets = value;
notifyListeners();
}
/// 获取生效尺寸
Rect get wmBounds => _state._wmBounds;
///获取总展示尺寸
Rect get displayBounds => _state._displayBounds;
/// {@macro utopia.hierarchy.WindowEntryUtils.entriesByFocus}
List<LiveWindowEntry> get entriesByFocus =>
WindowEntryUtils.getEntriesByFocus(_entries, _focusHierarchy);
/// {@macro utopia.hierarchy.WindowEntryUtils.sortedEntries}
List<LiveWindowEntry> get sortedEntries =>
WindowEntryUtils.getSortedEntries(_entries, _focusHierarchy);
/// {@macro utopia.hierarchy.WindowEntryUtils.isFocused}
bool isFocused(String id) => WindowEntryUtils.isFocused(_focusHierarchy, id);
}
在实际上层使用中,主要是通过该 Controller 实现窗口管理器的窗口创建与销毁操作。
WindowHierarchy
对 WindowHierarchyController 有了概念后,接下来来看 WindowHierarchy :
class WindowHierarchy extends StatelessWidget {
// 上节提到的 Controller
final WindowHierarchyController controller;
/// 负责具体的布局操作
final LayoutDelegate layoutDelegate;
/// 是否使用父组件的尺寸作为 [controller.displayBounds]
/// 如果为 false,总是使用 [MediaQueryData.size]
final bool useParentSize;
// 上层业务通常使用这个
const WindowHierarchy({
required this.controller,
required this.layoutDelegate,
this.useParentSize = false,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (!useParentSize) return _buildChild(MediaQuery.of(context).size);
return LayoutBuilder(
builder: (context, constraints) => _buildChild(constraints.biggest),
);
}
/// _WindowHierarchyInternal 需要关注
Widget _buildChild(Size size) {
return _WindowHierarchyInternal(
controller: controller,
layoutDelegate: layoutDelegate,
size: size,
);
}
}
内部包含一个 _WindowHierarchyInternal:
/// 这是一个 StatefulWidget
class _WindowHierarchyInternal extends StatefulWidget {
final WindowHierarchyController controller;
final LayoutDelegate layoutDelegate;
final Size size;
const _WindowHierarchyInternal({
required this.controller,
required this.layoutDelegate,
required this.size,
});
@override
State<_WindowHierarchyInternal> createState() =>
_WindowHierarchyInternalState();
}
class _WindowHierarchyInternalState extends State<_WindowHierarchyInternal> {
@override
void initState() {
super.initState
// 相互关联
widget.controller._provideState(this);
}
// 供外界刷新用
void _requestRebuild() {
setState(() {});
}
Rect get _wmBounds => widget.controller.wmInsets.deflateRect(_displayBounds);
Rect get _displayBounds => Offset.zero & widget.size;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: widget.controller, // 将 Controller 关联进去
builder: (context, _) => _LayoutBuilder(
delegate: widget.layoutDelegate,
entries: widget.controller.rawEntries,
focusHierarchy: widget.controller.focusHierarchy,
),
);
}
}
窗口缩放逻辑
窗口的缩放逻辑会放在哪里呢?放在 WindowFeature 的责任链里。
具体实现分为几个类:
- ResizeWindowFeature: WindowFeature 处理上层逻辑
- WindowResizeGestureDetector 响应具体拖动手势
下面分别来看。
ResizeWindowFeature
用户可以设置一系列属性:
// 最小尺寸
static const WindowPropertyKey<Size> minSize =
WindowPropertyKey<Size>('feature.resize.minSize', Size.zero);
// 最大尺寸
static const WindowPropertyKey<Size> maxSize =
WindowPropertyKey<Size>('feature.resize.maxSize', Size.infinite);
// 是否允许调整大小
static const WindowPropertyKey<bool> allowResize =
WindowPropertyKey<bool>('feature.resize.allowResize', true);
build 方法:
@override
Widget build(BuildContext context, Widget content) {
final WindowPropertyRegistry properties = WindowPropertyRegistry.of(context);
final LayoutState layout = LayoutState.of(context);
final WindowEventHandler? eventHandler = WindowEventHandler.maybeOf(context);
// 创建手势组件 WindowResizeGestureDetector
return WindowResizeGestureDetector(
borderThickness: 8,
// listeners 条件判断:如果允许更改大小、且没有吸附、且非全屏,则允许改变
listeners: properties.resize.allowResize &&
layout.dock == WindowDock.none &&
!layout.fullscreen
? _getListeners(context) // 允许改变就是把监听传进去
: null,
// 改变开始时发送一个事件
onStartResize: () => eventHandler?.onEvent(
WindowResizeStartEvent(timestamp: DateTime.now()),
),
// 改变结束时发送一个事件
onEndResize: () => eventHandler?.onEvent(
WindowResizeEndEvent(timestamp: DateTime.now()),
),
// 窗口内容
child: content,
);
}
_getListeners 方法定义了一个方法字典:
Map<Alignment, GestureDragUpdateCallback> _getListeners(
BuildContext context,
) {
return {
Alignment.topLeft: (details) =>
_onPanUpdate(context, details, top: true, left: true),
Alignment.topCenter: (details) =>
_onPanUpdate(context, details, top: true),
Alignment.topRight: (details) =>
_onPanUpdate(context, details, top: true, right: true),
Alignment.centerLeft: (details) =>
_onPanUpdate(context, details, left: true),
Alignment.centerRight: (details) =>
_onPanUpdate(context, details, right: true),
Alignment.bottomLeft: (details) =>
_onPanUpdate(context, details, bottom: true, left: true),
Alignment.bottomCenter: (details) =>
_onPanUpdate(context, details, bottom: true),
Alignment.bottomRight: (details) =>
_onPanUpdate(context, details, bottom: true, right: true),
};
}
根据不同的 key 映射到不同的回调方法,在每个回调方法中,调用 _onPanUpdate 进行统一处理。
在 _onPanUpdate 中,计算出窗口的新尺寸,改尺寸会更新到 LayoutState 中,从而通过 Provider 机制进行响应式更新。
WindowResizeGestureDetector
该组件掌管窗口缩放的手势响应。
在窗口的顶部,需要有三个缩放感受器:左上角的对角线缩放,上边的上下缩放,右上角的对角线缩放。对应代码为:
Widget buildFrame(BuildContext context) {
return Column(
children: [
Row(
children: [
buildGestureDetector(
borderThickness,
borderThickness,
listeners![Alignment.topLeft],
SystemMouseCursors.resizeUpLeft,
),
Expanded(
child: buildGestureDetector(
null,
borderThickness,
listeners![Alignment.topCenter],
SystemMouseCursors.resizeUp,
),
),
buildGestureDetector(
borderThickness,
borderThickness,
listeners![Alignment.topRight],
SystemMouseCursors.resizeUpRight,
),
],
),
buildGestureDetector 用于统一创建缩放感受器:
Widget buildGestureDetector(
double? width,
double? height,
GestureDragUpdateCallback? onPanUpdate,
SystemMouseCursor cursor,
) {
return MouseRegion(
cursor: cursor,
child: SizedBox(
width: width,
height: height,
child: GestureDetector(
onPanStart: onStartResize != null ? (_) => onStartResize!() : null,
onPanUpdate: onPanUpdate,
onPanEnd: onEndResize != null ? (_) => onEndResize!() : null,
),
),
);
}
其中的回调方法,都是由前面小节中传入进来的,即 listeners![Alignment.topLeft]。
窗口拖动逻辑
窗口拖动能力,实际上指的是窗口工具栏空白区域,支持拖动空白区域实现窗口移动。
具体代码位于 ToolbarWindowFeature 中,手势感受器:
GestureDetector(
onPanStart: (details) {
if (layout.dock != WindowDock.none) {
layout.dock = WindowDock.none;
layout.position = details.globalPosition +
Offset(
-layout.size.width / 2,
-properties.toolbar.size / 2,
);
}
},
onPanUpdate: (details) {
layout.position += details.delta;
},
onDoubleTap: () {
if (layout.dock == WindowDock.maximized) {
layout.dock = WindowDock.none;
} else {
layout.dock = WindowDock.maximized;
}
},
),
其中 layout 仍然是 LayoutState,改变后又能够响应式监听了。
窗口边缘吸附
这是一个非常实用的功能,许多窗口管理器都会附带。utopia 是如何实现这一功能的呢?
在 LayoutState 中有一个 WindowDock 枚举属性:
late WindowDock _dock = info.dock;
WindowDock 内部是一个枚举,声明了支持的吸附形式:
enum WindowDock {
none,
maximized,
left,
right,
topLeft,
topRight,
bottomLeft,
bottomRight,
}
可以看到,最大化也被定义为一种枚举形式。
在 utopia 中,只给出了对吸附的声明,但是没有给出实现。具体实现需要看 pangolin。
位于 PangolinLayoutDelegate 中。FreeformLayoutDelegate 是 utopia 提供的一种默认实现,而 PangolinLayoutDelegate 是另外实现了一套,并在新实现中包含了吸附逻辑。
除此之外,还要实现 tollbar 拖动到屏幕指定位置后,更新 dock 状态,实现闭环。
具体的实现逻辑是,还是在 tollbar 的移动手势监听中,判断窗口是否位于屏幕边缘,如果是,则更改 _dock 属性。在 LayoutDelegate 中,要监听 _dock 状态,优先将窗口摆放到固定吸附位置。
比如,判断是否位于屏幕边缘的算法是:
WindowDock _getDockForPosition(Rect bounds, Offset position) {
final Rect topLeft = Rect.fromLTWH(
bounds.left,
bounds.top,
PunkToolbar.dockEdgeSize,
PunkToolbar.dockEdgeSize,
);
final Rect left = Rect.fromLTWH(
bounds.left,
bounds.top + PunkToolbar.dockEdgeSize,
PunkToolbar.dockEdgeSize,
bounds.height - PunkToolbar.dockEdgeSize * 2,
);
final Rect bottomLeft = Rect.fromLTWH(
bounds.left,
bounds.bottom - PunkToolbar.dockEdgeSize,
PunkToolbar.dockEdgeSize,
PunkToolbar.dockEdgeSize,
);
final Rect topRight = Rect.fromLTWH(
bounds.right - PunkToolbar.dockEdgeSize,
bounds.top,
PunkToolbar.dockEdgeSize,
PunkToolbar.dockEdgeSize,
);
final Rect right = Rect.fromLTWH(
bounds.right - PunkToolbar.dockEdgeSize,
bounds.top + PunkToolbar.dockEdgeSize,
PunkToolbar.dockEdgeSize,
bounds.height - PunkToolbar.dockEdgeSize * 2,
);
final Rect bottomRight = Rect.fromLTWH(
bounds.right - PunkToolbar.dockEdgeSize,
bounds.bottom - PunkToolbar.dockEdgeSize,
PunkToolbar.dockEdgeSize,
PunkToolbar.dockEdgeSize,
);
final Rect maximized = Rect.fromLTWH(
bounds.left + PunkToolbar.dockEdgeSize,
bounds.top,
bounds.width - PunkToolbar.dockEdgeSize * 2,
PunkToolbar.dockEdgeSize,
);
if (topLeft.contains(position)) return WindowDock.topLeft;
if (left.contains(position)) return WindowDock.left;
if (bottomLeft.contains(position)) return WindowDock.bottomLeft;
if (topRight.contains(position)) return WindowDock.topRight;
if (right.contains(position)) return WindowDock.right;
if (bottomRight.contains(position)) return WindowDock.bottomRight;
if (maximized.contains(position)) return WindowDock.maximized;
return WindowDock.none;
}